Phase 4.5 — Settings Refactor and Live-Site Fixes

Arc started 2026-07-10, after Phase 4. Sections are Roman numerals and steps within them are lettered; each step's title says what it does, followed by a sentence on what was proposed and, once worked, a few on what was done and anything learned. Marker legend: done, next, ? open decision, unmarked = planned.

I. Fix live-site crashes and load

  1. A. Stop the yioop.com out-of-memory crashes and CPU pinning. A crawl flood on one page's wiki history plus spam searches drove repeated out-of-memory crashes and a pinned core. Bounded the PackedTableTools dictionary cache to a byte budget with least-recently-used eviction, took the markdown and mediawiki parsers linear with MarkUpScanner::peekLine, clamped each index entry's NUM_DOCS, stopped a spaces-only list marker spinning skipBlankLines, and moved history-revision rendering to the browser. Every fix has tests, holds byte-for-byte against the confirmed head, and is live.
  2. B. Stop the pollett.org mail server wedging on slow DNS. The single event loop stalled up to sixty seconds inside a blocking dns_get_record on inbound DKIM/SPF/DMARC and outbound mail-exchanger lookups, so one slow sender froze the daemon. Routed every lookup through a new AsyncDnsResolver that queries over a non-blocking socket, yields the loop, and is bounded by MAIL_DNS_TIMEOUT; a unit test covers name encode and decode, compression pointers, and hand-built replies.

II. Split Server Settings into two activities

The one Server Settings screen mixed machine-level configuration with per-account policy; these steps split it and build out the new account-policy controls.

  1. A. Move the settings field sets onto a Servers screen and a User, Roles, Groups screen. Partition the existing field sets between the two activities with no new fields, as a pure move that keeps every control and locale key working. Landed as a pure move.
  2. B. Add a Role Group Limits field set with per-role storage and a read path. Give each role caps on how many groups it may own and how large those groups may grow, an account's cap being the most generous across its roles. A ROLE_LIMITS table holds one row per role (-1 means unlimited) with a version 117 migration, the screen edits every role's caps, and getUserGroupLimits resolves the maximum across an account's roles. Enforcement is step E.
  3. C. Add a Moderation field set with an on/off toggle. Gather the moderation constants into one group and add a switch for whether moderation runs at all. The field set carries MODERATION_FLAG_THRESHOLD plus a new USE_MODERATION, and flagGroupItem only routes a flagged item to the moderation group when USE_MODERATION is on.
  4. D. Retire GIT_MAX_REPO_SIZE for a resource-memory cap and add a Developer role. Fold the standalone git repository-size limit into the per-page resource-memory limit, and add a role that holds only the Feeds and Wikis activity. The git push check reads MAX_PAGE_RESOURCE_MEMORY, and a Developer role (version 118 migration and Createdb seed) holds Feeds and Wikis with no modifier, unlike the User role.
  5. E. Enforce the role group limits at the points that create or grow a group. Check getUserGroupLimits at group create, member add, wiki-page add, resource upload, and post, and refuse past the cap.
    Done: GroupModel gains pure count methods (countGroupsOwnedByUser, countGroupWikiPages, countGroupThreads, countThreadPosts, joining the existing countGroupUsers); all enforcement lives in SocialComponent so the models stay independent of RoleModel. Two helpers, overUserGroupLimit and overGroupLimit, resolve the acting user's or the group owner's caps through getUserGroupLimits and refuse past a cap with redirectWithMessage. Group create is held to the acting user's MAX_GROUPS_OWNED; the wrappers addGroupItemWithinLimits and addUserGroupWithinLimits guard the feed and membership adds for MAX_GROUP_THREADS, MAX_THREAD_POSTS, and MAX_GROUP_MEMBERS; the new-wiki-page path is held to MAX_GROUP_WIKI_PAGES; and handleResourceUploads enforces MAX_THREAD_RESOURCES by file count for thread posts and MAX_PAGE_RESOURCE_MEMORY by folder bytes for pages. Seven refusal strings and a GroupModelCountsTest were added.
  6. F. Rework the Profile.php constants onto the live accessor so settings changes skip a restart. A Profile.php constant is fixed at boot, so a setting read as C\NAME needs a restart to change; a p($label, $value, $set) in Config.php backs each setting with a per-process cache seeded from the boot constant and overridden whenever updateProfile rewrites Profile.php, so a read through C\p('NAME') sees the new value at once. Landed the accessor (keyed on array_key_exists so a stored zero holds, resolved against the configs namespace), the updateProfile write hook, and the updateProfileFields fallback, with a unit test. The settings reads, including the moderation flags, were then moved to C\p group by group and verified live.
  7. G. Add per-role cost and charge-frequency billing. Charge a per-role cost in credits on a frequency (Never, Once, Monthly, Semi-Annual, Yearly). Done with the sellable-roles work: a role's ROLE_LIMITS row carries ROLE_COST and CHARGE_FREQUENCY, both editable in the roles admin panel and read by the account-page purchase and renewal paths.

Open decisions: ? relabel the current settings activity display name to “Servers”; ? whether Git Wiki Pages moves fully to the new activity or keeps a foot on the Servers screen.

III. Multi-domain hosting and small interface fixes

  1. A. Serve seekquarry.com and frise.com from one Yioop instance. Get multi-domain handling solid enough that one code base and index host answers for both domains, each with its own landing group and branding, rather than separate installs. Planned.
    Landing routing unified onto the static controller: a domain routed to a landing group in the Web Server field set used to serve that group's Main page through the group controller, while the Public-group landing and the “Use Wiki Public Main Page” option both went through the static controller. Both routed cases now take the static path. The router in src/index.php sends any positive routed group id to the static controller and passes the id on a $_SERVER['ROUTE_GROUP_ID'] variable a browser cannot set, since it is not an HTTP_ header, so only an admin-set DOMAIN_ROUTE chooses the group. StaticController gained a staticGroupId() resolver (the routed id when present and positive, else the world-readable Public group) and reads every wiki page, header, footer, alias, and resource from it in place of a hardcoded PUBLIC_GROUP_ID. Only the bare landing request is re-homed; other c=static requests still resolve to the Public group. A StaticControllerTest covers the resolver, and an end-to-end read confirmed a routed group's Main page is served while the default falls back to Public.
    Routed sub-pages and their self links: serving only the landing left the group's own links broken, since a link like [[Bob]] in a routed group's page is baked at save time as that group's read url on the group controller and, drawn under the static controller, became a static/<id>?a=wiki… url with no clean route (an error page). Two changes fix this. The router now resolves the request host to its landing group for every static request, not just the bare landing, so a p/Bob clean url on a routed domain reads that group's Bob page; the host comes from the admin-set DOMAIN_ROUTE table, so a request parameter cannot choose the group. And the static controller rewrites a routed group's baked self links to their p/ form as it serves the page, body, header, and footer alike. The rewrite keys on the page_name query the read url ends with rather than on the exact base url, so it holds whether the page was saved with clean urls on or off. The rewrite covers a page's own links; cross-group @@group@page@@ links and [[pages]] index links are left as they are, and a page missing in the routed group is not looked up in the Public group. End-to-end on the built-in server: the routed landing shows its Main with the Bob link drawn as p/Bob, a static request for that page on the routed host serves the routed group's copy, and the same request on an unrouted host still serves the Public group.
  2. B. Give the Manage Crawl start-crawl [+] control a single-line accessible name. Its accessible name renders as the letters of “Add” stacked on three lines. Landed a white-space:nowrap guard on .icon-glyph, confirmed on the live browser.
  3. C. Fix the Add Role form's vertically-stacked Name label. The “Name:” label rendered one letter per line. Removed a stray min-width:100% on the input cell, the only one in the views, which was collapsing the label column in Chrome; verified the fixed form renders on one line.

IV. Other work

Tasks that surfaced while doing the above and stand on their own.

  1. A. Restore wiki source, history, and page-list access. Several related regressions in wiki viewing. Historical revisions render their resources server-side, a {{require-signin}} left literal inside <nowiki> no longer fires, the whole-page sign-in warning is width-bounded, the Tools “Wiki Pages” link is hidden where the public group forbids its page list, and an empty public_source falls back to the group's page-source setting.
  2. B. Move wiki-revision diff rendering to the browser. A diff built its longest-common-subsequence table server-side on every crawler visit. The two revisions now ride to the browser and renderWikiDiff in help.js computes the diff there, checked line for line against the server diff(); this also fixed a latent array_slice(..., 0) that collapsed any no-common-tail diff to coarse.
  3. C. Instrument mail server down-events. A silent mail outage left no trace, since the heartbeat closure never captured the server object and threw every interval. Fixed that capture and added a shutdown handler that records a fatal with loop position, memory, and fiber and connection counts; catchable stop signals are logged, and the resolver logs a lookup no name server answers in time.
  4. D. Build shared test tables from ProfileModel definitions. Seven model tests stood up shared tables with hand-copied CREATE TABLE statements that could drift. Each now calls ProfileModel::initializeSql and executes the matching create_statements entry, so the tests track schema changes; MAIL_SECRET and GIT_APP_CODE, which ProfileModel does not define, keep their local definitions.
  5. E. Keep the admin signed in when the session cookie name or CSRF token field name changes. Renaming either on the Session Parameters panel signed the admin out: the browser kept a cookie under the old name while the server began expecting the new one, and a token-field rename dropped the token from the follow-up redirect. The renameSessionCookie migration and a token carry-forward now run on the panel's real save path, the updatetypes case of security(), just before updateProfile. An earlier copy of the migration sat in updateProfileFields, which no SESSION_NAME form reaches, so it never ran and is removed. Verified live against the secure server that both renames keep the session.
  6. F. Let users buy roles and subscribe to sellable ones. A role is put on sale by giving its ROLE_LIMITS row a ROLE_COST above zero and a CHARGE_FREQUENCY other than "never" (both already editable in the roles admin panel). A new USER_ROLE.EXPIRES column (database version 120, default FOREVER) records when a bought grant lapses; the permission queries in UserModel now ignore a grant past its expiry, so a lapsed role stops conferring its activities. On the account page, when any role is for sale, a Roles row under the language setting lists the user's roles, gives each subscribed role an unsubscribe link, and offers a change-password-style Purchase form that debits the role's cost from the credit ledger and grants it with an expiry one billing period out (a "once" purchase never lapses). Viewing the account also runs a credits-forward pass that spends the balance to push each lapsed subscription's expiry forward, one period at a time, for as many periods as the balance covers. Reworking the roles admin limits screen to fetch only the selected role's row, with an unsaved-changes confirm on dropdown change, is the immediate follow-on.

Other work accomplished